🎖️GitЯра🎖️
Commit 500ab7a0cb9dcff875117863317150da401df6cd
Parents : dc4a734
Author : Jeremiah K <17190268+jeremiah-k@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-07-24T11:28:50-05:00
Committer : GitHub <noreply@github.com>
Date : 2026-07-24T16:28:50Z
fix(firmware): decode manifests independent of content type (#6400)
Changes
4 files changed, 127 insertions(+), 6 deletions(-)
Diff
diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts
index 35580b2693..66927ab52d 100644
--- a/core/network/build.gradle.kts
+++ b/core/network/build.gradle.kts
@@ -68,6 +68,7 @@ kotlin {
commonTest.dependencies {
implementation(projects.core.testing)
implementation(libs.kable.core) // Kable exception types for BLE failure-injection tests
+ implementation(libs.ktor.client.mock)
}
}
}
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/service/ApiService.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/service/ApiService.kt
index 8f45510582..ddd5db981b 100644
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/service/ApiService.kt
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/service/ApiService.kt
@@ -38,7 +38,17 @@ import org.meshtastic.core.model.NetworkFirmwareReleases
private const val NIGHTLY_INDEX_URL =
"https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/master/firmware-nightly/index.json"
-private val nightlyIndexJson = Json { ignoreUnknownKeys = true }
+private val firmwareJson = Json {
+ isLenient = true
+ ignoreUnknownKeys = true
+ coerceInputValues = true
+}
+
+/**
+ * Decodes a GitHub release manifest independently of Ktor content negotiation. GitHub release assets commonly use
+ * `application/octet-stream` even when their payload is JSON.
+ */
+internal fun decodeFirmwareReleaseManifest(body: String): FirmwareReleaseManifest = firmwareJson.decodeFromString(body)
/** Client for the Meshtastic public API (device hardware catalog and firmware releases). */
interface ApiService {
@@ -81,16 +91,13 @@ class ApiServiceImpl(private val client: HttpClient) : ApiService {
override suspend fun getFirmwareReleases(): NetworkFirmwareReleases = client.get("github/firmware/list").body()
override suspend fun getFirmwareReleaseManifest(manifestUrl: String): FirmwareReleaseManifest =
- client.get(manifestUrl).body()
+ decodeFirmwareReleaseManifest(client.get(manifestUrl).bodyAsText())
override suspend fun getNightlyFirmware(): NetworkFirmwareNightly? {
val response = client.get(NIGHTLY_INDEX_URL)
return when {
response.status == HttpStatusCode.NotFound -> null
-
- response.status.isSuccess() ->
- nightlyIndexJson.decodeFromString<NetworkFirmwareNightly>(response.bodyAsText())
-
+ response.status.isSuccess() -> firmwareJson.decodeFromString<NetworkFirmwareNightly>(response.bodyAsText())
else -> error("Unexpected HTTP ${response.status} fetching nightly firmware index")
}
}
diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/service/ApiServiceTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/service/ApiServiceTest.kt
new file mode 100644
index 0000000000..d7248953fa
--- /dev/null
+++ b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/service/ApiServiceTest.kt
@@ -0,0 +1,112 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.network.service
+
+import io.ktor.client.HttpClient
+import io.ktor.client.engine.mock.MockEngine
+import io.ktor.client.engine.mock.respond
+import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
+import io.ktor.http.ContentType
+import io.ktor.http.HttpHeaders
+import io.ktor.http.headersOf
+import io.ktor.serialization.kotlinx.json.json
+import kotlinx.coroutines.test.runTest
+import kotlinx.serialization.SerializationException
+import org.meshtastic.core.model.FirmwareTarget
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+
+class ApiServiceTest {
+ @Test
+ fun `service decodes release manifest served as octet stream`() = runTest {
+ val manifestUrl = "https://downloads.example/firmware/manifest.json"
+ val engine = MockEngine { request ->
+ assertEquals(manifestUrl, request.url.toString())
+ respond(
+ content =
+ """
+ {
+ "version": "2.7.26.54e0d8d",
+ "targets": [
+ {"board": "t-deck", "platform": "esp32"}
+ ]
+ }
+ """
+ .trimIndent(),
+ headers = headersOf(HttpHeaders.ContentType, ContentType.Application.OctetStream.toString()),
+ )
+ }
+ val client = HttpClient(engine) { install(ContentNegotiation) { json() } }
+
+ try {
+ val manifest = ApiServiceImpl(client).getFirmwareReleaseManifest(manifestUrl)
+
+ assertEquals("2.7.26.54e0d8d", manifest.version)
+ assertEquals(listOf(FirmwareTarget(board = "t-deck", platform = "esp32")), manifest.targets)
+ } finally {
+ client.close()
+ }
+ }
+
+ @Test
+ fun `firmware release manifest decoder accepts release asset JSON and unknown fields`() {
+ val manifest =
+ decodeFirmwareReleaseManifest(
+ """
+ {
+ "version": "2.7.26.54e0d8d",
+ "targets": [
+ {"board": "t-deck", "platform": "esp32", "future": true}
+ ],
+ "unknown": "ignored"
+ }
+ """
+ .trimIndent(),
+ )
+
+ assertEquals("2.7.26.54e0d8d", manifest.version)
+ assertEquals(listOf(FirmwareTarget(board = "t-deck", platform = "esp32")), manifest.targets)
+ }
+
+ @Test
+ fun `firmware release manifest decoder coerces explicit nulls to defaults`() {
+ val manifest =
+ decodeFirmwareReleaseManifest(
+ """
+ {
+ "version": null,
+ "targets": [
+ {
+ "board": "t-deck",
+ "platform": null
+ }
+ ]
+ }
+ """
+ .trimIndent(),
+ )
+
+ assertEquals("", manifest.version)
+ assertEquals(FirmwareTarget(board = "t-deck", platform = ""), manifest.targets.single())
+ }
+
+ @Test
+ fun `firmware release manifest decoder rejects malformed JSON`() {
+ assertFailsWith<SerializationException> { decodeFirmwareReleaseManifest("not-json") }
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 24a33741ee..51c58f89d0 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -233,6 +233,7 @@ ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negoti
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-java = { module = "io.ktor:ktor-client-java", version.ref = "ktor" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
+ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-network = { module = "io.ktor:ktor-network", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
Served by rngit 1.5.2 - Generated in 0.11s